Luo vankkaa, tyyppiturvallista koodia JavaScriptissä ja TypeScriptissä mallisovituksen tyyppivarmistuksilla, erotelluilla unioneilla ja tyhjentävyystarkistuksilla. Vältä ajonaikaiset virheet.
JavaScript-mallisovitus ja tyyppivarmistus: Opas tyyppiturvalliseen mallisovitukseen
Nykyaikaisessa ohjelmistokehityksessä monimutkaisten tietorakenteiden hallinta on päivittäinen haaste. Käsittelitpä API-vastauksia, hallinnoit sovelluksen tilaa tai prosessoit käyttäjätapahtumia, joudut usein tekemisiin datan kanssa, joka voi olla yhdessä monista eri muodoista. Perinteinen lähestymistapa sisäkkäisillä if-else-lausekkeilla tai yksinkertaisilla switch-rakenteilla on usein monisanainen, virhealtis ja kasvualusta ajonaikaisille virheille. Entä jos kääntäjä voisi olla turvaverkkosi ja varmistaa, että olet käsitellyt jokaisen mahdollisen skenaarion?
Tässä kohtaa tyyppiturvallisen mallisovituksen voima astuu kuvaan. Lainamaalla konsepteja funktionaalisista ohjelmointikielistä, kuten F#, OCaml ja Rust, ja hyödyntämällä TypeScriptin voimakasta tyyppijärjestelmää, voimme kirjoittaa koodia, joka ei ole ainoastaan ilmaisukykyisempää ja luettavampaa, vaan myös perustavanlaatuisesti turvallisempaa. Tämä artikkeli on syväsukellus siihen, miten voit saavuttaa vankkaa, tyyppiturvallista mallisovitusta JavaScript- ja TypeScript-projekteissasi, eliminoiden kokonaisen luokan bugeja ennen kuin koodisi edes ajetaan.
Mitä mallisovitus tarkalleen on?
Ytimeltään mallisovitus on mekanismi arvon vertaamiseksi sarjaan malleja. Se on kuin superlatautunut switch-lauseke. Sen sijaan, että tarkistettaisiin vain yhtäsuuruutta yksinkertaisten arvojen (kuten merkkijonojen tai numeroiden) kanssa, mallisovitus antaa sinun tarkistaa datasi rakennetta tai muotoa vastaan.
Kuvittele, että lajittelet fyysistä postia. Et tarkista vain, onko kirjekuori osoitettu "Matti Meikäläiselle". Saatat lajitella postin eri mallien perusteella:
- Onko se pieni, suorakulmainen kirjekuori, jossa on postimerkki? Se on todennäköisesti kirje.
- Onko se suuri, pehmustettu kirjekuori? Se on luultavasti paketti.
- Onko siinä läpinäkyvä muovi-ikkuna? Se on lähes varmasti lasku tai virallinen kirje.
Mallisovitus koodissa tekee saman asian. Sen avulla voit kirjoittaa logiikkaa, joka sanoo: "Jos datani näyttää tältä, tee näin. Jos sillä on tämä muoto, tee jotain muuta." Tämä deklaratiivinen tyyli tekee aikeesi paljon selvemmäksi kuin monimutkainen imperatiivisten tarkistusten verkko.
Klassinen ongelma: Turvaton `switch`-lauseke
Aloitetaan yleisellä skenaariolla JavaScriptissä. Rakennamme grafiikkasovellusta ja meidän täytyy laskea eri muotojen pinta-aloja. Jokainen muoto on olio, jolla on `kind`-ominaisuus kertomassa, mikä muoto on kyseessä.
// Meidän muoto-oliomme
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// ONGELMA: Mikään ei estä meitä käyttämästä shape.sideLength tässä
// ja saamasta `undefined`. Tämä johtaisi arvoon NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Tämä puhdas JavaScript-koodi toimii, mutta se on hauras. Sillä on kaksi suurta ongelmaa:
- Ei tyyppiturvallisuutta: `'circle'`-tapauksen sisällä JavaScript-ajoympäristö ei tiedä, että `shape`-oliolla on taatusti `radius`-ominaisuus eikä `sideLength`-ominaisuutta. Yksinkertainen kirjoitusvirhe kuten `shape.raduis` tai väärä oletus kuten `shape.width`-ominaisuuden käyttö johtaisi `undefined`-arvoon ja ajonaikaisiin virheisiin (kuten `NaN` tai `TypeError`).
- Ei tyhjentävyystarkistusta: Mitä tapahtuu, jos uusi kehittäjä lisää `Triangle`-muodon? Jos hän unohtaa päivittää `getArea`-funktion, se palauttaa yksinkertaisesti `undefined` kolmioille, ja tämä bugi saattaa jäädä huomaamatta, kunnes se aiheuttaa ongelmia täysin eri osassa sovellusta. Tämä on hiljainen virhe, vaarallisin bugityyppi.
Ratkaisun osa 1: Perusta TypeScriptin erotelluilla unioneilla
Ratkaistaksemme nämä ongelmat, meidän on ensin löydettävä tapa kuvailla "data, joka voi olla yksi monesta asiasta" tyyppijärjestelmälle. TypeScriptin erotellut unionit (tunnetaan myös nimillä tagged unions tai algebralliset tietotyypit) ovat täydellinen työkalu tähän.
Erotellulla uniolla on kolme komponenttia:
- Joukko erillisiä rajapintoja tai tyyppejä, jotka edustavat kutakin mahdollista varianttia.
- Yhteinen, literaaliominaisuus (erottelija), joka on kaikissa varianteissa, kuten `kind: 'circle'`.
- Unionityyppi, joka yhdistää kaikki mahdolliset variantit.
`Shape`-erotellun unionin rakentaminen
Mallinnetaan muotomme käyttäen tätä kuviota:
// 1. Määrittele rajapinnat kullekin variantille
interface Circle {
kind: 'circle'; // Erottelija
radius: number;
}
interface Square {
kind: 'square'; // Erottelija
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // Erottelija
width: number;
height: number;
}
// 2. Luo unionityyppi
type Shape = Circle | Square | Rectangle;
Tämän `Shape`-tyypin avulla olemme kertoneet TypeScriptille, että `Shape`-tyyppisen muuttujan on pakko olla `Circle`, `Square` tai `Rectangle`. Se ei voi olla mitään muuta. Tämä rakenne on tyyppiturvallisen mallisovituksen peruskallio.
Ratkaisun osa 2: Tyyppivarmistukset ja kääntäjän ohjaama tyhjentävyys
Nyt kun meillä on eroteltu unioni, TypeScriptin kontrollivirran analyysi voi tehdä taikojaan. Kun käytämme `switch`-lauseketta erottelijaominaisuudelle (`kind`), TypeScript on tarpeeksi älykäs kaventamaan tyyppiä kunkin `case`-lohkon sisällä. Tämä toimii voimakkaana, automaattisena tyyppivarmistuksena (type guard).
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript tietää, että `shape` on `Circle` tässä!
// shape.sideLength -viittaus olisi käännösaikainen virhe.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript tietää, että `shape` on `Square` tässä!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript tietää, että `shape` on `Rectangle` tässä!
return shape.width * shape.height;
}
}
Huomaa välitön parannus: `case 'circle'` -tapauksen sisällä `shape`-muuttujan tyyppi kaventuu `Shape`-tyypistä `Circle`-tyyppiin. Jos yrität käyttää `shape.sideLength`-ominaisuutta, koodieditorisi ja TypeScript-kääntäjä ilmoittavat siitä välittömästi virheenä. Olet poistanut kokonaisen luokan ajonaikaisia virheitä, jotka johtuvat väärien ominaisuuksien käytöstä!
Todellisen turvallisuuden saavuttaminen tyhjentävyystarkistuksella
Olemme ratkaisseet tyyppiturvallisuusongelman, mutta entä hiljainen virhe, kun lisäämme uuden muodon? Tässä kohtaa pakotamme tyhjentävyystarkistuksen. Kerromme kääntäjälle: "Sinun on varmistettava, että olen käsitellyt jokaisen mahdollisen `Shape`-tyypin variantin."
Voimme saavuttaa tämän nokkelalla tempulla käyttämällä `never`-tyyppiä. `never`-tyyppi edustaa arvoa, jota ei pitäisi koskaan esiintyä. Lisäämme `switch`-lausekkeeseemme `default`-tapauksen, joka yrittää sijoittaa `shape`-muuttujan `never`-tyyppiseen muuttujaan.
Luodaan pieni aputoiminto tähän:
function assertNever(value: never): never {
throw new Error(`Käsittelemätön erotellun unionin jäsen: ${JSON.stringify(value)}`);
}
Päivitetään nyt `getArea`-funktiomme:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// Jos olemme käsitelleet kaikki tapaukset, `shape` on tyyppiä `never` tässä.
// Jos ei, se on käsittelemätön tyyppi, aiheuttaen käännösaikaisen virheen.
return assertNever(shape);
}
}
Tässä vaiheessa koodi kääntyy täydellisesti. Mutta katsotaanpa, mitä tapahtuu, kun lisäämme uuden `Triangle`-muodon:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Lisää uusi muoto unioniin
type Shape = Circle | Square | Rectangle | Triangle;
Välittömästi `getArea`-funktiomme näyttää käännösaikaisen virheen `default`-tapauksessa:
Argument of type 'Triangle' is not assignable to parameter of type 'never'.
Tämä on vallankumouksellista! Kääntäjä toimii nyt turvaverkkonamme. Se pakottaa meidät päivittämään `getArea`-funktion käsittelemään `Triangle`-tapauksen. Hiljaisesta ajonaikaisesta bugista on tullut selkeä ja kuuluva käännösaikainen virhe. Korjaamalla virheen takaamme, että logiikkamme on täydellinen.
function getArea(shape: Shape): number { // Nyt korjauksella
switch (shape.kind) {
// ... muut tapaukset
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Lisää uusi tapaus
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Kun lisäämme `case 'triangle'` -tapauksen, `default`-tapauksesta tulee saavuttamaton millekään kelvolliselle `Shape`-tyypille, `shape`-muuttujan tyyppi muuttuu tuossa kohdassa `never`-tyypiksi, virhe katoaa ja koodimme on jälleen täydellinen ja oikein.
`switch`-lausekkeen yli: Deklaratiivinen mallisovitus kirjastoilla
Vaikka `switch`-lauseke tyhjentävyystarkistuksella on uskomattoman voimakas, sen syntaksi voi silti tuntua hieman monisanaiselta. Funktionaalisen ohjelmoinnin maailma on pitkään suosinut ilmaisupohjaisempaa, deklaratiivisempaa lähestymistapaa mallisovitukseen. Onneksi JavaScript-ekosysteemi tarjoaa erinomaisia kirjastoja, jotka tuovat tämän elegantin syntaksin TypeScriptiin täydellä tyyppiturvallisuudella ja tyhjentävyydellä.
Yksi suosituimmista ja tehokkaimmista kirjastoista tähän on `ts-pattern`.
Refaktorointi `ts-pattern`-kirjastolla
Katsotaan, miltä `getArea`-funktiomme näyttää uudelleenkirjoitettuna `ts-pattern`-kirjastolla:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Varmistaa, että kaikki tapaukset on käsitelty, aivan kuten meidän `never`-tarkistuksemme!
}
Tämä lähestymistapa tarjoaa useita etuja:
- Deklaratiivinen ja ilmaisuvoimainen: Koodi näyttää sääntöjen sarjalta, joka selkeästi toteaa "kun syöte vastaa tätä mallia, suorita tämä funktio".
- Tyyppiturvalliset takaisinkutsut: Huomaa, että `.with({ kind: 'circle' }, (c) => ...)` -rakenteessa `c`:n tyyppi päätellään automaattisesti ja oikein `Circle`-tyypiksi. Saat täyden tyyppiturvallisuuden ja automaattisen täydennyksen takaisinkutsun sisällä.
- Sisäänrakennettu tyhjentävyys: `.exhaustive()`-metodi palvelee samaa tarkoitusta kuin `assertNever`-apufunktiomme. Jos lisäät uuden variantin `Shape`-unioniin, mutta unohdat lisätä sille `.with()`-lausekkeen, `ts-pattern` aiheuttaa käännösaikaisen virheen.
- Se on lauseke: Koko `match`-lohko on lauseke, joka palauttaa arvon, mikä mahdollistaa sen käytön suoraan `return`-lausekkeissa tai muuttujien sijoituksissa, mikä voi tehdä koodista siistimpää.
`ts-pattern`-kirjaston edistyneet ominaisuudet
`ts-pattern` yltää paljon pidemmälle kuin yksinkertainen erottelijan sovitus. Se mahdollistaa uskomattoman voimakkaat ja monimutkaiset mallit.
- Predikaattisovitus `.when()`-metodilla: Voit sovittaa ehtoon perustuen.
- Jokerimerkkisovitus `P.any` ja `P.string` jne. avulla: Sovita olion muotoon ilman erottelijaa.
- Oletustapaus `.otherwise()`-metodilla: Tarjoaa siistin tavan käsitellä kaikki tapaukset, joita ei ole eksplisiittisesti sovitettu, vaihtoehtona `.exhaustive()`-metodille.
// Käsittele suuret neliöt eri tavalla
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Muuttuu:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* erikoislogiikka suurille neliöille */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Sovita mihin tahansa olioon, jolla on numeerinen `radius`-ominaisuus
.with({ radius: P.number }, (obj) => `Löytyi ympyränkaltainen olio säteellä ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Tukematon muoto: ${shape.kind}`)
Käytännön esimerkkejä maailmanlaajuiselle yleisölle
Tämä malli ei ole tarkoitettu vain geometrisille muodoille. Se on uskomattoman hyödyllinen monissa todellisen maailman ohjelmointiskenaarioissa, joita kehittäjät ympäri maailmaa kohtaavat päivittäin.
1. API-pyyntöjen tilojen käsittely
Yleinen tehtävä on datan noutaminen API:sta. Tämän pyynnön tila voi tyypillisesti olla yksi useista mahdollisuuksista: alku, lataus, onnistunut tai virhe. Eroteltu unioni on täydellinen tämän mallintamiseen.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// Käyttöliittymäkomponentissasi (esim. React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Tervetuloa! Napsauta painiketta ladataksesi profiilisi.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Tämän mallin avulla on mahdotonta vahingossa renderöidä käyttäjäprofiilia, kun tila on vielä latauksessa, tai yrittää käyttää `state.data`-ominaisuutta, kun tila on `error`. Kääntäjä takaa käyttöliittymäsi loogisen johdonmukaisuuden.
2. Tilan hallinta (esim. Redux, Zustand)
Tilan hallinnassa lähetät toimintoja (actions) päivittääksesi sovelluksen tilaa. Nämä toiminnot ovat klassinen käyttökohde erotelluille unioneille.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` on oikein tyypitetty tässä!
// ... logiikka tuotteen lisäämiseksi
return { ...state, /* päivitetyt tuotteet */ };
case 'REMOVE_ITEM':
// ... logiikka tuotteen poistamiseksi
return { ...state, /* päivitetyt tuotteet */ };
// ... ja niin edelleen
default:
return assertNever(action);
}
}
Kun `CartAction`-unioniin lisätään uusi toimintotyyppi, `cartReducer` ei käänny, ennen kuin uusi toiminto on käsitelty, mikä estää sinua unohtamasta sen logiikan toteuttamista.
3. Tapahtumien käsittely
Käsittelitpä sitten WebSocket-tapahtumia palvelimelta tai käyttäjän vuorovaikutustapahtumia monimutkaisessa sovelluksessa, mallisovitus tarjoaa siistin, skaalautuvan tavan reitittää tapahtumat oikeille käsittelijöille.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`Käyttäjä ${e.userId} kirjautui sisään.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Käsittelemätön tapahtuma: ${e.event}`));
}
Hyödyt yhteenvetona
- Luodinkestävä tyyppiturvallisuus: Poistat kokonaisen luokan ajonaikaisia virheitä, jotka liittyvät vääriin tietorakenteisiin (esim.
Cannot read properties of undefined). - Selkeys ja luettavuus: Mallisovituksen deklaratiivinen luonne tekee ohjelmoijan aikomuksesta ilmeisen, mikä johtaa helpommin luettavaan ja ymmärrettävään koodiin.
- Taattu täydellisyys: Tyhjentävyystarkistus tekee kääntäjästä valppaan kumppanin, joka varmistaa, että olet käsitellyt jokaisen mahdollisen datavariantin.
- Vaivaton refaktorointi: Uusien varianttien lisääminen tietomalleihisi muuttuu turvalliseksi, ohjatuksi prosessiksi. Kääntäjä osoittaa jokaisen paikan koodikannassasi, joka vaatii päivitystä.
- Vähemmän toistokoodia: Kirjastot kuten `ts-pattern` tarjoavat tiiviin, voimakkaan ja elegantin syntaksin, joka on usein paljon siistimpi kuin perinteiset kontrollivirran lausekkeet.
Johtopäätös: Omaksu käännösaikainen varmuus
Siirtyminen perinteisistä, turvattomista kontrollivirran rakenteista tyyppiturvalliseen mallisovitukseen on paradigman muutos. Kyse on tarkistusten siirtämisestä ajonajasta, jossa ne ilmenevät bugeina käyttäjillesi, käännösaikaan, jossa ne näkyvät hyödyllisinä virheinä sinulle, kehittäjälle. Yhdistämällä TypeScriptin erotellut unionit tyhjentävyystarkistuksen voimaan – joko manuaalisella `never`-vakuutuksella tai `ts-pattern`-kirjaston avulla – voit rakentaa sovelluksia, jotka ovat vankempia, ylläpidettävämpiä ja kestävämpiä muutoksille.
Seuraavan kerran kun huomaat kirjoittavasi pitkää `if-else if-else` -ketjua tai `switch`-lauseketta merkkijono-ominaisuudelle, pysähdy hetkeksi miettimään, voisitko mallintaa datasi eroteltuna unionina. Investoi tyyppiturvallisuuteen. Tulevaisuuden minäsi ja maailmanlaajuinen käyttäjäkuntasi kiittävät sinua vakaudesta ja luotettavuudesta, jonka se tuo ohjelmistollesi.